fix: LightGBM Ensure non-duplicate column names - #2508
fix: LightGBM Ensure non-duplicate column names#2508Rana Singh (ranadeepsingh) wants to merge 9 commits into
Conversation
|
/azp run |
|
Hey Rana Singh (@ranadeepsingh) 👋! We use semantic commit messages to streamline the release process. Examples of commit messages with semantic prefixes:
To test your commit locally, please follow our guild on building from source. |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2508 +/- ##
==========================================
+ Coverage 86.76% 87.03% +0.27%
==========================================
Files 338 338
Lines 18785 18817 +32
Branches 1804 1803 -1
==========================================
+ Hits 16299 16378 +79
+ Misses 2486 2439 -47 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
4cb326b to
1b74030
Compare
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
This PR fixes LightGBM training failures caused by duplicate feature names (including collisions after LightGBM’s space-to-underscore normalization) by ensuring slot/feature names are unique and validated before the first native call that consumes them. It also propagates feature names onto the streaming reference Dataset and refactors reference-dataset creation for clearer lifecycle management.
Changes:
- De-duplicate feature/slot names (from both
slotNamesandAttributeGroupmetadata) using LightGBM-style normalization rules. - Validate slot names earlier in
fitto fail fast with actionable errors before native feature-name calls. - Name the streaming reference Dataset consistently and add regression tests covering duplicate/collision scenarios.
Show a summary per file
| File | Description |
|---|---|
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala |
Ensure unique feature names (including normalized collisions) and validate names before native calls. |
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala |
Refactor reference Dataset creation and (now) apply feature names to reference datasets. |
lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala |
Add tests for duplicate feature names from metadata and explicit slotNames, including collision edge cases. |
Review details
Tip
Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:94
serializeAndCleanupfrees the Dataset handle. If the caller also adds defensive cleanup (needed for failures before serialization), this risks double-free. Prefer making Dataset lifetime ownership explicit: serialize here, but free the Dataset in the caller'sfinallyso all failure paths free exactly once.
LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSerializeReferenceToBinary(
datasetHandle, bufferHandlePtr, lenPtr), "Serialize ref")
val bufferLen: Int = lightgbmlib.intp_value(lenPtr)
log.info(s"Created serialized reference dataset of length $bufferLen")
LightGBMUtils.validate(lightgbmlib.LGBM_DatasetFree(datasetHandle), "Free Dataset")
toByteArray(bufferHandlePtr, bufferLen)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:587
getSlotNamesWithMetadata(and thereforeensureUniqueFeatureNames) is invoked multiple times during a singlefitin streaming mode (e.g., once insidecalculateRowStatisticsand again when building theTrainingContextinexecuteTraining). SinceensureUniqueFeatureNameslogs a warning when it renames duplicates, the same warning can be emitted more than once per batch, which is noisy and can look like multiple independent problems. Consider computing the (unique) feature name array once per batch and threading it through to all consumers.
// Get feature names to set on the reference dataset (ensures unique names for Spark 3.5+)
val featureNames = getSlotNamesWithMetadata(featuresSchema)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:287
validateSlotNamescurrently validates the de-duplicated names returned bygetSlotNamesWithMetadata. If the input contains invalid characters and also has duplicates,ensureUniqueFeatureNamescan append a suffix (e.g.,bad,name_1), and the thrownIllegalArgumentExceptionwill list names the user never supplied, making the error harder to act on. Validate the raw names first (either explicitslotNamesor metadata-derived names) and only then de-duplicate for LightGBM.
This issue also appears on line 585 of the same file.
private def validateSlotNames(featuresSchema: StructField): Unit = {
val slotNamesOpt = getSlotNamesWithMetadata(featuresSchema)
val pattern = new Regex("[\",:\\[\\]{}]")
slotNamesOpt.foreach(slotNames => {
val badSlotNames = slotNames.flatMap(slotName =>
if (pattern.findFirstIn(slotName).isEmpty) None else Option(slotName))
if (!badSlotNames.isEmpty) {
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
Rebase onto current master and address three defects found while reviewing the original change. Duplicate detection was incomplete in two ways, and both still produced the error this branch exists to fix: - A generated name could collide with an original name appearing later, so ["Column_", "Column_", "Column__1"] still failed with "Feature (Column__1) appears more than one time". All original names are now reserved up front. - LightGBM replaces spaces with underscores before comparing feature names, so "a b" and "a_b" are one feature natively and failed with "Feature (a_b) appears more than one time". Uniqueness is now decided on the normalized form while the original names are still emitted. Naming the streaming reference Dataset introduces an LGBM_DatasetSetFeatureNames call inside calculateRowStatistics, which runs earlier in trainOneDataBatch than validateSlotNames did. Invalid names therefore surfaced as the opaque native "Do not support special JSON characters in feature name" instead of the actionable IllegalArgumentException, breaking the existing "Verify LightGBM Regressor with bad column names fails early" test. validateSlotNames now runs as soon as featuresSchema is resolved. Also: - Remove the bulk-mode retry fallback. Its guard required the message to contain "dataset create", but the duplicate-name error comes from LGBM_DatasetSetFeatureNames, so it could never match; and BulkPartitionTask sets the same names, so the retry would fail identically. It also mutated the estimator's dataTransferMode param mid-fit. - Free the voidpp handle in createDatasetFromSamples, matching deserializeReferenceDataset. The previous code leaked it on every streaming fit(). - Skip feature names with a warning when their count does not match numCols, since LGBM_DatasetSetFeatureNames reads numCols entries. - Revert stray setUseBarrierExecutionMode(true) in the ranker and regressor test data base classes; they are EstimatorFuzzing bases, so the flag applied to every ranker and regressor test. Tests: VerifyLightGBMCommon 9/9, regressor/ranker stream 26/26, bulk and network suites 52/52, scalastyle clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ame message
Addresses two review comments.
createReferenceDatasetFromSample allocated the native Dataset and only freed
it on the success path, inside serializeAndCleanup. Naming the Dataset added a
new throwing call between the allocation and that free, so a duplicate or
invalid feature name leaked the Dataset. The handle is now freed in a finally
that covers both naming and serialization, and serializeAndCleanup becomes
serializeReference since it no longer owns cleanup. The free is intentionally
not validated: throwing from the finally would mask the original failure.
The invalid slot name message listed backslash as a rejected character, but the
regex never matched it and LightGBM does not reject it either. Its CheckAllowedJSON
rejects exactly " , : [ ] { }. The message now matches both the regex and the
native behavior. The regex is deliberately unchanged; adding backslash to it
would reject names LightGBM accepts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
af6664d to
cd9560d
Compare
There was a problem hiding this comment.
Review details
Suppressed comments (1)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:211
getSlotNamesWithMetadataassumes everyAttributeentry is non-null. Spark attribute metadata can contain null slots (this file already handlescase (null, _)ingetCategoricalIndexes), and a null here would NPE during name extraction and break training.
Guard null attributes and fall back to the default index-based name when an entry is null.
val colNames = attributes.indices.map(_.toString).toArray
attributes.foreach(attr =>
attr.index.foreach(index => colNames(index) = attr.name.getOrElse(index.toString)))
// Ensure unique feature names to avoid LightGBM error:
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Review feedback: the length guard added for the reference dataset only covered one of four call sites. LGBM_DatasetSetFeatureNames reads numCols entries from the array, and slotNames is user-supplied and never length-checked upstream, so the bulk and streaming per-partition paths could still pass a short array into native code. Verified rather than assumed. With the guard reverted, both new tests fail: Dataset set feature names call failed in LightGBM with error: basic_string: construction from null is not valid The native read runs off the end of the Java String[], reads null, and std::string construction from null throws -- crashing the executor task and failing the whole fit. It reproduces in both streaming and bulk transfer modes. Move the guard into LightGBMDataset.setFeatureNames so all naming paths are protected, and add regression tests for both transfer modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Good catch — you were right, and it was worse than a theoretical risk. Fixed by centralizing the guard in I reverted the guard and ran the new tests to confirm the exposure is real rather than assume it: \
Changes:
Validation: /azp run |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala:186
- The code skips naming when
featureNamesArray.length != numCols, but the preceding comment only explains the short-array risk. Since the condition also skips longer arrays, the comment should be updated to reflect the full mismatch behavior to avoid confusion.
// LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
// is an out-of-bounds native read. slotNames is user-supplied and unvalidated, so guard
// every dataset-naming path here rather than at individual call sites. LightGBM falls
// back to its own generated names when naming is skipped.
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:80
- The guard skips naming when
names.length != numCols, but the comment only justifies the short-array case. This is misleading for future maintainers (the current condition also skips when the array is longer thannumCols). Update the comment to describe the full mismatch behavior and rationale.
if (names.length != numCols) {
// LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
// would be an out-of-bounds native read. Skip naming rather than risk it; LightGBM then
// falls back to its own generated names, which is the behavior prior to this feature.
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Related Issues/PRs
Fixes #2242
Fixes the LightGBM training failure
Feature (Column_) appears more than one time.What changes are proposed in this pull request?
LightGBM rejects a Dataset whose feature names repeat. Spark can surface repeated names through the
AttributeGroupmetadata of the features column (and a user can pass repeatedslotNamesdirectly), so training failed in the nativeLGBM_DatasetSetFeatureNamescall with:Changes
getSlotNamesWithMetadatanow routes both metadata-derived names and explicitslotNamesthroughensureUniqueFeatureNames, which appends a numeric suffix to repeated names while preserving order (feature names are positional in LightGBM).createReferenceDatasetFromSamplenow accepts the feature names and applies them, so the reference Dataset carries the same names as the per-partition Datasets.createReferenceDatasetFromSampleintocreateDatasetFromSamples,setFeatureNamesIfProvided, andserializeAndCleanup.validateSlotNamespreviously ran only when the features column carriedAttributeGroupmetadata, so explicitslotNamescontaining" , : [ ] { }were never checked. See the behavior-change note below.LGBM_DatasetSetFeatureNamescall insidecalculateRowStatistics, which runs earlier intrainOneDataBatchthanvalidateSlotNamesdid.validateSlotNamesis now called as soon asfeaturesSchemais resolved.Correctness notes
Generated names cannot collide with later original names. A dedup pass that only remembers the names it has already emitted is not sufficient. For
["Column_", "Column_", "Column__1"]it renames the secondColumn_toColumn__1, which the third slot already owns, so LightGBM still fails — on the exact error this PR exists to fix. Every original name is therefore reserved up front, and generated names are reserved as they are handed out. This is covered by a dedicated test that was confirmed to fail against the naive implementation withFeature (Column__1) appears more than one time.Uniqueness is decided on LightGBM's terms. LightGBM replaces every space in a feature name with an underscore before checking for duplicates, so
"a b"and"a_b"are one feature natively while being two distinct strings in Scala. Comparing raw strings therefore still let the target error through:Collisions are now detected on the normalized form while the original names are still what get emitted. Confirmed empirically in both directions.
No bulk-mode retry. An earlier revision of this branch caught the failure and retried the batch with
dataTransferMode=bulk. That fallback was removed because it could not work:BulkPartitionTaskcallssetFeatureNameswith the same still-duplicated names, so the retry fails identically. This is the decisive point and holds regardless of which native call reports the duplicate.dataset create. That matches the call site in #2242 (Dataset create from samples), but on currentmasterthe duplicate is reported byLGBM_DatasetSetFeatureNames, whose component string isDataset set feature names, so the guard no longer matches the failure it was written for. Confirmed empirically; the failure text is quoted above.It also mutated the estimator's
dataTransferModeparam mid-fit, which is visible to concurrent callers and to the model built during the retry. Deduplicating the names up front removes the need for a fallback entirely.Native handle cleanup.
createDatasetFromSamplesfrees itsvoidpphandle in afinally, matchingdeserializeReferenceDataset. The pre-existing code allocated two handles and freed only the unused one, leaking the one it actually used on every streamingfit().The reference
Datasetitself is now also freed in afinallyspanning both naming and serialization. Previously it was freed only on the success path, so the naming call this PR adds would have leaked it whenever a name was rejected. That free is intentionally not routed throughLightGBMUtils.validate, because throwing from thefinallywould mask the original failure.Backslash is not rejected. The invalid-slot-name message listed
\as a disallowed character, but the regex never matched it and LightGBM does not reject it either —CheckAllowedJSONrejects exactly" , : [ ] { }. The message now matches both the regex and the native behavior. The regex is deliberately unchanged; adding\would reject names LightGBM accepts.Feature-name count guard.
LGBM_DatasetSetFeatureNamesreadsnumColsentries from the array, so a shorter array would be an out-of-bounds native read. When the counts disagree the names are skipped with a warning, which is the behavior prior to this change.Validation must precede the native call. LightGBM rejects
" , : [ ] { }in feature names itself, but only withDo not support special JSON characters in feature name.— it does not say which column is at fault. Because naming the reference Dataset moves the first native feature-name call earlier infit, validating afterwards let that opaque error win the race and broke the existingVerify LightGBM Regressor with bad column names fails earlytest. Validating up front restores the actionableIllegalArgumentException, and the test now fails in 0.3s instead of after the sampling pass — which is what "fails early" is asserting.Behavior changes
validateSlotNamesnow also validates explicitly suppliedslotNames. A job that passedslotNamescontaining" , : [ ] { }while the features column had no attribute metadata previously reached LightGBM unchecked and now fails fast with the existing, actionableIllegalArgumentException.How is this patch tested?
Added to
VerifyLightGBMCommon:Verify duplicate feature names are handled correctlyAttributeGroupmetadataVerify explicit slotNames parameter is usedslotNamesstill appliedVerify duplicate explicit slotNames are made uniqueslotNamesVerify a generated slot name cannot collide with a later original nameVerify names differing only by space vs underscore are made uniqueValidated locally on Java 11 against the full CI dataset set:
VerifyLightGBMCommon9/9,VerifyLightGBMRegressorStreamandVerifyLightGBMRankerStream26/26 (includingVerify LightGBM Regressor with bad column names fails early),VerifyLightGBMRegressorBulk,VerifyLightGBMRankerBulk,VerifyLightGBMClassifierStreamOnly,NetworkManagerSuiteandTrainUtilsSuite52/52, andlightgbm/scalastylepluslightgbm/Test/scalastylereport 0 errors.An earlier revision of this branch also set
setUseBarrierExecutionMode(true)inLightGBMRankerTestDataandLightGBMRegressorTestData. Those areEstimatorFuzzingbase classes, so the flag applied to every ranker and regressor test rather than to anything this PR changes; it has been reverted.Rebased onto current
master(68 commits, including #2595 and #2578) with no conflicts.Does this PR change any dependencies?
Does this PR add a new feature? If so, have you added samples on website?